Add 'balancedweighted' VM allocation algorithm - #14109
Conversation
Counts VMs occupying each host in a zone, pod or cluster, optionally only those that changed state recently. One query for the whole scope rather than one per host. Signed-off-by: Brad House <bhouse@nexthop.ai>
StatsCollector already polls CPU and memory utilisation for every host, but it keeps only the newest sample and nothing uses it for placement. - fold those samples into an exponentially weighted moving average - weight by elapsed time, so a missed poll decays correctly instead of over-weighting the previous value - report nothing usable until a host has been sampled, so callers can fall back to allocation figures Two settings: host.load.sample.interval and host.load.half.life. Signed-off-by: Brad House <bhouse@nexthop.ai>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #14109 +/- ##
============================================
+ Coverage 19.79% 19.82% +0.03%
- Complexity 20015 20067 +52
============================================
Files 6371 6375 +4
Lines 575954 576314 +360
Branches 70521 70568 +47
============================================
+ Hits 113997 114260 +263
- Misses 449530 449607 +77
- Partials 12427 12447 +20
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Opt-in via vm.allocation.algorithm. Existing algorithms and the default are untouched. Allocated capacity alone is a poor ranking under heavy overprovisioning: it is measured against a total already multiplied by the overprovisioning factor, so a host under real strain still reports a low percentage and keeps being chosen. Anything allocation cannot see - VMs the scheduler has lost track of, guests using more than they asked for - is invisible. Ranks on a blend, lower is better: | term | source | |---------------------|-------------------------------------------| | CPU allocated | op_host_capacity, over the inflated total | | CPU utilisation | moving average of real usage | | memory allocated | op_host_capacity, over the inflated total | | memory utilisation | moving average of real usage | | VM count | VMs on the host | | recent starts | VMs started within the last few minutes | A dominant resource term is added on top so a host nearly out of any one resource does not rank well on a good average. Hosts measurably too busy are held back, unless that would leave nowhere to deploy. Selection is random among the best few rather than strictly ordered: capacity is only charged once a VM starts, so concurrent deployments all read the same figures and strict ordering makes them agree on one host. All weights and thresholds are settings, most cluster scoped. Signed-off-by: Brad House <bhouse@nexthop.ai>
Allocated fraction was measured against the wrong total. op_host_capacity stores totals raw and overprovisioning is applied when they are read, so dividing by the stored total made the fraction reach 1 at the host's physical size. On a cluster overcommitted 10 times every host clamped to 1, killing both the allocation term and the dominant resource term - on exactly the clusters this algorithm is for. - apply the cluster ratio to the denominator - drop hosts missing a CPU or memory capacity row instead of scoring the missing resource as untouched, which made them rank first Utilisation thresholds could be bypassed. Held-back hosts were appended before the random spread was applied, so the spread could shuffle a busy host into the lead. With 1 healthy host and a spread of 3, two thirds of deployments picked an over-threshold host. - spread over healthy hosts only, before anything else is appended A host with no load samples was treated as idle. It was exempt from the thresholds and its dominant resource term fell back to allocation, so a host with broken stats outranked every measured host and collected the deployments. - rank hosts we cannot measure behind every host we can - when nothing can be measured, ranking falls back to allocation as before Signed-off-by: Brad House <bhouse@nexthop.ai>
Utilisation average
- a host whose agent stops reporting kept vouching for itself forever:
StatsCollector hands back the previous entry when a poll fails, and
that unchanged reading was folded again every minute. Detect the
repeat, and expire an average that stops being updated
- sample on a scheduled executor catching Throwable, not a Timer, which
dies permanently and silently on one escaping error
- only collect when an algorithm that reads the figures is selected
- read the half life once per sample rather than inside the map update
- document what getCpuUtilization means per hypervisor: it is what this
assumes on KVM, a reservation figure on VMware, and scaled by core
count on XenServer
Scoring
- a negative weight would rank the most loaded host first; floor at
zero and say so
- zeroing all six terms no longer discards the dominant resource term
- read weights once per ranking instead of once per host
Queries
- one query per ranking instead of two, returning both counts
- count Stopping VMs, which still hold their host
- correct the doc: every host in scope is returned, including empty ones
Tests
- cover rank() end to end, which is where the defects were: the
capacity denominator, the thresholds, the spread and the ordering of
measured against unmeasured hosts
- the distribution simulation drew different random streams per arm, so
the arms saw different workloads. Fix the workload up front and add
an allocation-only-plus-spread control, which shows the scoring and
not the spread is what evens out real load
Signed-off-by: Brad House <bhouse@nexthop.ai>
87c06e3 to
726e632
Compare
DaanHoogland
left a comment
There was a problem hiding this comment.
clgtm and finctionality seems sane to me. testing and experimenting needed ;)
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Adds an opt-in VM allocation algorithm (balancedweighted) that ranks hosts using a weighted blend of allocated capacity, measured utilisation, VM counts, and recent starts, plus supporting infrastructure and tests.
Changes:
- Introduces
HostLoadTracker(EWMA sampling of host CPU/memory) andWeightedHostScorer(scoring + thresholding + selection spread). - Adds a new DAO query to count VMs per host including “recently started” VMs.
- Adds extensive unit/simulation tests and wires new beans + config options into allocator/planner.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/src/main/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorer.java | Implements scoring, ranking, threshold holdback, and selection spread for balancedweighted. |
| server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoadTracker.java | Adds periodic host-load sampling and EWMA tracking used by weighted placement. |
| server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoad.java | Adds value object for smoothed utilisation samples. |
| server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java | Hooks balancedweighted into allocation by ranking with WeightedHostScorer. |
| server/src/main/resources/META-INF/cloudstack/server-allocator/spring-server-allocator-context.xml | Registers HostLoadTracker and WeightedHostScorer beans. |
| engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java | Adds API to count VMs per host, including recent state changes. |
| engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java | Implements the per-host VM count query used by the scorer. |
| api/src/main/java/com/cloud/host/HostScoringWeights.java | Introduces shared config keys for CPU/memory allocated/used weights. |
| api/src/main/java/com/cloud/deploy/DeploymentPlanner.java | Adds balancedweighted to the allocation algorithm enum. |
| api/src/main/java/com/cloud/deploy/DeploymentClusterPlanner.java | Extends vm.allocation.algorithm config help text and allowed values. |
| server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerTest.java | Unit-tests scoring behavior and selection spread. |
| server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerRankTest.java | End-to-end rank() tests including thresholds/spread and capacity denominator behavior. |
| server/src/test/java/com/cloud/agent/manager/allocator/impl/HostLoadTrackerTest.java | Tests EWMA tracking, staleness, and duplicate-reading handling. |
| server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedPlacementDistributionTest.java | Simulation/regression test to validate distribution improvements under churn/hidden load. |
| PendingReleaseNotes | Documents the new allocation algorithm, motivation, and tuning knobs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // a cut-off in the future counts nothing as recent, which is what a null asks for | ||
| long cutOff = changedStateAfter != null ? changedStateAfter.getTime() : Long.MAX_VALUE; | ||
| pstmt.setTimestamp(index++, new Timestamp(cutOff)); |
| } catch (SQLException e) { | ||
| throw new CloudRuntimeException("DB Exception on: " + sql, e); | ||
| } catch (Throwable e) { | ||
| throw new CloudRuntimeException("Caught: " + sql, e); | ||
| } |
| return new ConfigKey<?>[] {HostScoringWeights.CpuAllocatedWeight, HostScoringWeights.CpuUsedWeight, HostScoringWeights.MemoryAllocatedWeight, HostScoringWeights.MemoryUsedWeight, | ||
| VmCountWeight, RecentStartWeight, DominantResourceWeight, RecentStartWindow, ExpectedVmsPerHost, | ||
| CpuUtilisationThreshold, MemoryUtilisationThreshold, SelectionSpread}; |
vm_instance.update_time is a TIMESTAMP column, so there is no date that reliably means "never" - anything past 2038 is out of range. Counting with no cut-off used Long.MAX_VALUE, which is roughly year 292 million. The query now leaves the timestamp out entirely in that case rather than binding an impossible one. Also catch Exception rather than Throwable in the new query, and put the config key array one entry per line. Signed-off-by: Brad House <bhouse@nexthop.ai>
|
Thanks for the review, and for the approval @DaanHoogland — agreed that this wants real-world testing; it is opt-in behind On the Copilot comments:
Config key array formatting — done, one entry per line. |
|
This pull request has merge conflicts. Dear author, please fix the conflicts and sync your branch with the base branch. |
Description
This PR adds an opt-in VM allocation algorithm,
balancedweighted, forvm.allocation.algorithm. Existing algorithms and the default are untouched.The problem. The existing algorithms rank hosts on allocated capacity alone. Under a large overprovisioning factor that reads badly: allocation is measured against a total that has already been multiplied by the factor, so a host under real strain can still report a low percentage allocated and keep attracting new VMs. Anything allocation cannot see - guests using far more than they asked for, VMs the management server has lost track of - is invisible.
Concurrent deployments make it worse. Capacity is only charged once a VM starts, so every decision taken in the same moment reads the same figures, and strict ordering makes them all agree on one host.
What it ranks on. A blend, lower is better:
op_host_capacity, over the overprovisioned totalop_host_capacity, over the overprovisioned totalRecent starts exist because a VM that started moments ago is invisible to both allocation lag and a moving average, while often working hardest.
A dominant-resource term is added on top of the weighted mean, taking the larger of allocated and measured per resource, so that a host nearly out of any one resource does not rank well on a good average.
Then two things happen that ranking alone does not do:
The algorithm
Every candidate host gets a score in
[0, 1]; lower is better.Terms. Each is a fraction of that host's capacity for the resource, so the weights are directly comparable:
All six are clamped to
[0, 1]. The allocated terms divide by the overprovisioned total because that is what a host can hand out; dividing by the raw total makes every host on an overcommitted cluster read as full.Score.
dominantis the host's most stressed resource, and it takes the larger of allocated and measured per resource — memory reclaimed from idle guests is taken back the moment those guests get busy, so the optimistic reading should not win. Adding it on top of the mean stops a host that is fine on average but nearly out of one resource from ranking well. Both parts are convex combinations of values in[0, 1], so the score stays in[0, 1].When a host has no usable utilisation samples,
w_cuandw_mudrop out of both numerator and denominator, and the host is ranked behind every host that can be measured rather than being assumed idle.Moving average. Weighted by elapsed time, so a missed poll decays by the right amount instead of over-weighting the previous value:
Selection. Ranking alone is not enough: capacity is only charged once a VM starts, so concurrent deployments all read the same figures and strict ordering makes them agree on one host.
Settings
Weights are relative to each other; only their ratios matter.
0disables a term.The first four are defined in
apirather than alongside the allocator, because they describe howloaded a host is rather than anything specific to placement, and a DRS algorithm that ranks hosts
the same way should read the same settings instead of carrying a second copy that could disagree.
host.weighted.cpu.allocated.weight1.0host.weighted.cpu.used.weight2.0host.weighted.memory.allocated.weight1.0host.weighted.memory.used.weight2.0host.weighted.vm.count.weight1.0host.weighted.recent.start.weight2.0host.weighted.dominant.resource.weight1.0host.weighted.recent.start.window300host.weighted.expected.vms.per.host50host.weighted.cpu.utilisation.threshold0.851disableshost.weighted.memory.utilisation.threshold0.901disableshost.weighted.selection.spread31restores strict orderinghost.load.sample.interval60host.stats.intervalhost.load.half.life300host.load.stale.after600All are dynamic except
host.load.sample.interval.Measured effect. Simulation over a churning, heavily overprovisioned fleet where part of the real load is invisible to allocation, measuring how unevenly real load ends up distributed (max/mean across hosts, lower is better):
The middle row is a control: it isolates what the scoring contributes from what the randomisation contributes.
Utilisation figures come from a moving average of what
StatsCollectoralready polls but nothing used for placement. It is per management server and not persisted; every management server polls every host, so they converge. Until a server has samples, ranking falls back to allocation figures, and hosts that cannot be measured rank behind hosts that can rather than being assumed idle.Note the utilisation terms are only meaningful as intended on KVM.
getCpuUtilizationreports a reservation figure on VMware and is scaled by core count on XenServer; this is documented on the collector.All weights and thresholds are settings, most cluster scoped.
Types of changes
Feature/Enhancement Scale or Bug Severity
Feature/Enhancement Scale
How Has This Been Tested?
Unit tests, 39 new cases:
WeightedHostScorerTest- the scoring function: each term's direction, the dominant-resource term, unmeasured hosts, the utilisation gates, the selection spread.WeightedHostScorerRankTest-rank()end to end as the allocator calls it, with the capacity and VM-count queries mocked. Covers the overprovisioned denominator, a busy host ranking behind a quiet one at equal allocation, an over-threshold host never leading while a healthy one exists, unmeasured hosts ranking last, fallback to allocation when nothing is measured, and a host missing a capacity row.HostLoadTrackerTest- the moving average: first sample, a single spike not dominating, convergence, half-life, missed samples decaying by elapsed time rather than sample count, unchanged readings not folded twice, and a host that stops reporting becoming unusable.WeightedPlacementDistributionTest- the simulation above. Deterministic; the workload is fixed before any arm runs so all arms see identical VMs.Full
mvn testonapi,engine/schemaandserver, checkstyle and license checks enabled: 0 failures.How did you try to break this feature and the system with this change?
This went through two rounds of adversarial review. The defects found and fixed are worth listing, since they are the interesting part:
op_host_capacitystores totals raw and applies overprovisioning when they are read. Dividing by the stored total made the fraction reach 1 at the host's physical size, so on a cluster overcommitted ten times every host clamped to 1 and both the allocation term and the dominant-resource term went dead - on exactly the clusters this is for.StatsCollectorhands back the previous entry when a poll fails, and that unchanged reading was folded again every interval.Timer, which dies permanently and silently on one escaping error, leaving placement quietly back on allocation alone. It also ran regardless of whether the algorithm was selected.rank(), which is why it caught none of the above. Both fixed, and the spread-only control arm added.Other things checked: hosts missing a capacity row, all-zero weights,
podId/clusterIdbeing null, concurrent access to the shared average, and that the two per-ranking queries became one.